<!DOCTYPE html>
<html lang="zh">


<head>
  <meta charset="utf-8" />
  <title>无限画布（四向扩展）- 右键/双指拖拽版（已取消自动扩展）</title>
  <meta name="viewport" content="width=device-width,initial-scale=1, viewport-fit=cover">
  <script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
  <script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
  <style>
    html,
    body {
      height: 100%;
      margin: 0;
      padding: 0;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: linear-gradient(135deg, #1a1a2e, #16213e);
      color: #e6f7ff;
      overflow: hidden;
      /* 整个页面禁止默认滚动（已经打包为 app 时常用） */
    }


    #app {
      width: 98vw;
      height: 98vh;
      background: rgba(10, 15, 20, 0.9);
      position: relative;
      box-shadow: 0 0 20px rgba(0, 255, 200, 0.3);
      border-radius: 12px;
      overflow: hidden;
      /* 禁止外层滚动 */
      -webkit-overflow-scrolling: touch;
      border: 1px solid rgba(0, 200, 255, 0.2);
    }


    .toolbar {
      position: sticky;
      top: 15px;
      left: 15px;
      z-index: 120;
      background: rgba(15, 25, 35, 0.85);
      padding: 12px 15px;
      border-radius: 10px;
      display: flex;
      gap: 10px;
      align-items: center;
      flex-wrap: wrap;
      box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
      border: 1px solid rgba(0, 200, 255, 0.2);
    }


    button {
      background: rgba(25, 45, 65, 0.8);
      color: #51c991;
      border: 1px solid #47b776;
      padding: 3px 8px;
      border-radius: 8px;
      cursor: pointer;
      touch-action: manipulation;
      font-weight: 500;
      transition: all 0.3s ease;
      box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
    }


    button:hover {
      background: rgba(0, 180, 255, 0.3);
      transform: translateY(-2px);
      box-shadow: 0 4px 8px rgba(0, 200, 255, 0.4);
    }


    button:active {
      transform: translateY(1px);
    }


    button.active {
      background: #41ca83;
      color: #002233;
      border-color: #2c933f;
      box-shadow: 0 0 15px rgba(89, 219, 77, 0.6);
    }


    .canvas-container {
      position: relative;
      width: 100%;
      height: calc(100% - 70px);
      overflow: hidden;
      /* 禁止默认滚动条显示，改用右键/双指拖拽实现平移 */
      touch-action: none;
      /* 我们使用 pointer 事件自行处理单指绘制与双指平移 */
    }


    /* allow vertical page scroll by default */
    .scroller {
      position: relative;
      width: 100%;
    }


    /* spacer inside will determine scrollable area */
    canvas.drawing-canvas {
      position: absolute;
      left: 0;
      top: 0;
      background: #0a1018;
      z-index: 1;
      user-select: none;
      touch-action: none;
      /* 默认禁止触摸行为，使用 pointer API 自行控制 */
    }


    canvas.cursor-canvas {
      position: absolute;
      left: 0;
      top: 0;
      z-index: 2;
      pointer-events: none;
    }


    .sel-delete-btn {
      position: absolute;
      z-index: 130;
      background: #00ccff;
      color: #002b3d;
      border: none;
      padding: 8px 12px;
      border-radius: 6px;
      cursor: pointer;
      transform: translate(6px, 6px);
      font-weight: bold;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
    }


    .formula-btn {
      position: fixed;
      top: 20px;
      right: 15px;
      z-index: 140;
      background: #4bd187;
      color: #002b3d;
      border: none;
      padding: 5px 10px;
      border-radius: 8px;
      font-weight: bold;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
    }


    .formula-panel {
      position: fixed;
      top: 60px;
      right: 15px;
      z-index: 145;
      width: 200px;
      max-height: 65vh;
      overflow: auto;
      background: rgba(12, 20, 30, 0.95);
      border-radius: 12px;
      padding: 15px;
      color: #dfffd6;
      border: 1px solid rgba(0, 200, 255, 0.3);
      box-shadow: 0 8px 25px rgba(0, 0, 0, 0.6);
    }


    .hint {
      color: #6bbb68;
      font-size: 0.4rem;
      padding: 6px 10px;
      background: rgba(0, 100, 150, 0.2);
      border-radius: 6px;
      margin-left: 10px;
      display: none;
    }


    /* 配置面板样式 */
    .config-panel {
      position: absolute;
      top: 60px;
      background: rgba(15, 25, 35, 0.95);
      padding: 15px;
      border-radius: 10px;
      box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
      border: 1px solid rgba(0, 200, 255, 0.3);
      z-index: 125;
      width: 220px;
      transition: all 0.3s ease;
    }


    .config-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 12px;
      padding-bottom: 8px;
      border-bottom: 1px solid rgba(0, 200, 255, 0.2);
    }


    .config-title {
      color: #79c559;
      font-weight: bold;
      font-size: 1.1rem;
    }


    .close-btn {
      background: transparent;
      color: #ff5555;
      border: none;
      font-size: 1.3rem;
      cursor: pointer;
      width: 28px;
      height: 28px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 50%;
      transition: all 0.2s ease;
    }


    .close-btn:hover {
      background: rgba(255, 85, 85, 0.2);
      transform: scale(1.1);
    }


    .config-content {
      display: flex;
      flex-direction: column;
      gap: 12px;
    }


    .config-group {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }


    .config-label {
      color: #6cc784;
      font-size: 0.95rem;
      display: flex;
      align-items: center;
      gap: 8px;
    }


    input[type="color"] {
      width: 40px;
      height: 40px;
      border-radius: 8px;
      border: 2px solid #69bd5f;
      cursor: pointer;
      background: rgba(0, 50, 80, 0.5);
    }


    input[type="range"] {
      width: 100%;
      height: 6px;
      border-radius: 3px;
      background: rgba(0, 100, 150, 0.3);
      outline: none;
      -webkit-appearance: none;
    }


    input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 20px;
      height: 20px;
      border-radius: 50%;
      background: #76d276;
      cursor: pointer;
      box-shadow: 0 0 5px rgba(117, 212, 135, 0.8);
    }


    .value-display {
      min-width: 40px;
      text-align: center;
      background: rgba(0, 100, 150, 0.3);
      padding: 4px 8px;
      border-radius: 6px;
      font-weight: bold;
    }


    @media (max-width:768px) {
      .toolbar {
        top: 10px;
        left: 10px;
        padding: 10px;
        gap: 8px;
      }


      button {
        padding: 3px 8px;
        font-size: 0.5rem;
      }


      .hint {
        font-size: 0.8rem;
        margin-top: 8px;
        margin-left: 0;
        width: 100%;
      }
    }


    /deep/mjx-mtable > * > mjx-itable  > mjx-mtd {
      text-align: left!important;
    }
   /deep/ mjx-table > mjx-itable {
  text-align: left !important;
}
  </style>
</head>


<body>
  <div id="app">
    <div class="toolbar">
      <div style="position:relative">
        <button @click="togglePanel('brush')" :class="{active: activePanel==='brush'}">画笔</button>
        <div v-if="activePanel==='brush' && activePanel2==='true'" class="config-panel">
          <div class="config-header">
            <div class="config-title">画笔设置</div>
            <button class="close-btn" @click="activePanel2='false'">×</button>
          </div>
          <div class="config-content">
            <div class="config-group">
              <label class="config-label">
                颜色:
                <input type="color" v-model="color">
              </label>
            </div>
            <div class="config-group">
              <label class="config-label">
                宽度: <span class="value-display">{{size}}</span>
              </label>
              <input type="range" min="0.5" max="60" v-model.number="size">
            </div>
          </div>
        </div>
      </div>


      <div style="position:relative">
        <button @click="togglePanel('eraser')" :class="{active: activePanel==='eraser'}">橡皮</button>


        <div v-if="activePanel==='eraser' && activePanel2==='true'" class="config-panel">
          <div class="config-header">
            <div class="config-title">橡皮擦设置</div>
            <button class="close-btn" @click="activePanel2='false'">×</button>
          </div>
          <div class="config-content">
            <div class="config-group">
              <label class="config-label">
                大小: <span class="value-display">{{eraserSize}}</span>
              </label>
              <input type="range" min="1" max="120" v-model.number="eraserSize">
            </div>


          </div>
        </div>
      </div>


      <button @click="activateSelectTool" :class="{active: selectMode}">矩形</button>
      <button @click="toggleFreeSelect" :class="{active: freeSelectMode}">自选</button>

      <button @click="condition=true">更多</button>
      <div v-if="condition" style="position:relative">
        <button @click="expandLeft">向左扩展</button>
        <button @click="expandRight">向右扩展</button>
        <button @click="expandTop">向上扩展</button>
        <button @click="expandBottom">向下扩展</button>
        <button @click="condition=false">x</button>
      </div>

      <!-- 手动扩展按钮（替代自动扩展） -->
      <button @click="undo" :disabled="undoStack.length<=1">
        <
        </button>
          <button @click="redo" :disabled="redoStack.length===0">></button>
          <button @click="clearCanvas">清</button>


          <span class="hint">提示：左键/触控单指绘制；右键拖拽 或 双指拖拽 平移画布；使用上面的“扩展”按钮手动增加画布尺寸。</span>
    </div>


    <button class="formula-btn" @click="toggleFormulaPanel">公式库</button>


    <div class="canvas-container" ref="container">
      <div class="scroller" ref="scroller">
        <!-- spacer 决定可滚动区域大小（宽与高会随着 canvas 扩展而变） -->
        <div ref="spacer" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"></div>


        <!-- drawing canvas（绝对定位于 scroller 内） -->
        <canvas ref="drawingCanvas" class="drawing-canvas" @pointerdown.stop="onPointerDown"
          @pointermove.stop="onPointerMove" @pointerup.stop="onPointerUp" @pointercancel.stop="onPointerUp"
          @pointerout.stop="onPointerUp"></canvas>


        <canvas ref="cursorCanvas" class="cursor-canvas"></canvas>
      </div>

      <!-- 删除选区按钮 -->
      <button v-if="selection" class="sel-delete-btn" :style="{ left: deleteBtnLeft + 'px', top: deleteBtnTop + 'px' }"
        @pointerdown.stop @click.stop="deleteSelection">删除</button>


      <!-- 公式面板 -->
      <div v-if="showFormulaPanel" class="formula-panel" @click.stop>




        <div style="display:flex;justify-content:space-between;align-items:center;">
          <strong style="color:#aaffaa">数学公式库</strong>
          <div>
            <button @click="closeFormulaPanel">×</button>
          </div>
        </div>
        <div style="margin-top:12px;">
          <div v-for="(n,i) in filteredTree" :key="i" style="margin:10px 0;">
            <div @click="toggleNode(n)"
              style="cursor:pointer;color:#bfffbf;font-weight:500;padding:6px;background:rgba(0,80,120,0.2);border-radius:6px;">
              ▸ {{n.title}}</div>
            <div v-show="n._open" style="margin-left:10px;margin-top:8px;">
              <div v-for="(c,ci) in n.children" :key="ci" @click="selectFormula(c)"
                style="cursor:pointer;color:#b7ffd6;padding:6px 10px;border-radius:6px;margin:5px 0;background:rgba(0,60,100,0.15);">
                {{c.title}}
              </div>
            </div>
          </div>
        </div>
        <div v-if="selectedFormula" style="margin-top:15px;border-top:1px dashed rgba(0,255,0,0.1);padding-top:15px;">
          <div style="color:#aaffaa;font-weight:600;font-size:1.1rem;margin-bottom:10px;">{{selectedFormula.title}}
          </div>

          <div v-html="selectedFormula.latexHtml" style="text-align: left;"></div>
          <div
            style="margin-top:10px;color:#c6ffd6;font-size:0.95rem;background:rgba(0,50,80,0.2);padding:10px;border-radius:8px;">
            <div v-html="selectedFormula.descHtml" style="text-align: left;"></div>
          </div>
        </div>


        <!-- 上传并覆盖默认公式库（放进 .formula-panel 内合适位置） -->
        <div style="margin-top:8px;display:flex;gap:8px;align-items:center;">
          <button @click="$refs.jsonUploader.click()" style="padding:6px;border-radius:6px;">上传 JSON 并覆盖</button>
          <input type="file" accept=".json,application/json" ref="jsonUploader" style="display:none"
            @change="handleJsonUploadReplace">
        </div>



      </div>
    </div>
  </div>


  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          customFormulas: [],
          condition: false,
          activePanel2: 'true',
          // drawing
          drawing: false, lastX: 0, lastY: 0,
          color: '#00ffcc', size: 4, eraserSize: 20, isEraser: false, activePanel: null,
          drawingCtx: null, cursorCtx: null, eraserCursorX: null, eraserCursorY: null,


          // canvas size (初始大小，可根据需要调大)
          canvasWidth: 1600,     // 初始宽度
          canvasHeight: 2000,    // 初始高度
          maxDim: 60000,         // 最大允许尺寸（防止内存爆炸）
          expandStep: 1200,      // 每次扩展的像素步长（手动扩展使用）


          // selection
          selectMode: false, selecting: false, selectionStart: null, selectionEnd: null,
          selection: null, dragging: false, dragStart: null, resizing: false, resizeHandleSize: 12,
          startSelectionWidth: 0, startSelectionHeight: 0, startScaleX: 1, startScaleY: 1,
          freeSelectMode: false, freeSelecting: false, freePath: [],


          // history
          undoStack: [], redoStack: [], maxHistory: 50,


          deleteBtnLeft: 0, deleteBtnTop: 0,


          // formula
          showFormulaPanel: false,
          tree: [
            {
              title: '一、三角函数公式',
              _open: false,
              children: [
                { title: '基本关系与倒数', latex: "\\tan x=\\frac{\\sin x}{\\cos x} /n \\cot x=\\frac{\\cos x}{\\sin x} /n \\cot x=\\frac{1}{\\tan x} /n \\sec x=\\frac{1}{\\cos x} /n \\csc x=\\frac{1}{\\sin x}", desc: '定义关系与倒数函数' },
                { title: '恒等式与二倍角', latex: "1+\\tan^{2}x=\\sec^{2}x /n 1+\\cot^{2}x=\\csc^{2}x /n \\sin^{2}x+\\cos^{2}x=1 /n \\sin 2x = 2\\sin x\\cos x /n \\cos 2x = \\cos^{2}x - \\sin^{2}x", desc: '恒等式与二倍角公式' },
                { title: '降幂公式', latex: "\\sin^{2}x=\\frac{1-\\cos 2x}{2} /n \\cos^{2}x=\\frac{1+\\cos 2x}{2}", desc: '降幂用于积分/化简' }
              ]
            },
            {
              title: '二、对数与指数公式',
              _open: false,
              children: [
                { title: '对数恒等式', latex: "\\ln\\frac{a}{b}=\\ln a - \\ln b /n \\ln(ab)=\\ln a + \\ln b /n \\ln a^{b}=b\\ln a", desc: '对数运算规则' },
                { title: '指数幂运算', latex: "u^{v}=e^{v\\ln u} /n x^{a}\\cdot x^{b}=x^{a+b} /n \\frac{x^{a}}{x^{b}}=x^{a-b} /n (x^{a})^{b}=x^{ab} /n (ab)^{x}=a^{x}b^{x} /n \\left(\\frac{a}{b}\\right)^{x}=\\frac{a^{x}}{b^{x}} /n x^{-a}=\\frac{1}{x^{a}}", desc: '幂与指数的基本法则' },
                { title: '根与绝对值', latex: "\\sqrt[n]{x^{m}}=x^{m/n} /n \\sqrt{ab}=\\sqrt{a}\\sqrt{b} /n \\sqrt{\\frac{a}{b}}=\\frac{\\sqrt{a}}{\\sqrt{b}} /n |ab|=|a||b| /n \\left|\\frac{a}{b}\\right|=\\frac{|a|}{|b|}", desc: '根式与绝对值运算' },
                { title: '因式分解常用公式', latex: "x^{n}-1=(x-1)(x^{n-1}+\\cdots+1) /n a^{3}-b^{3}=(a-b)(a^{2}+ab+b^{2}) /n a^{3}+b^{3}=(a+b)(a^{2}-ab+b^{2})", desc: '多项式因式分解' }
              ]
            },
            {
              title: '三、极限',
              _open: false,
              children: [
                { title: '等价无穷小', latex: "\\sin x\\sim x /n \\tan x\\sim x /n \\arcsin x\\sim x /n \\arctan x\\sim x /n \\ln(1+x)\\sim x /n e^{x}-1\\sim x /n (1+x)^{\\alpha}-1\\sim \\alpha x /n 1-\\cos x\\sim\\frac{x^{2}}{2}", desc: '常用等价替换' },
                { title: '高阶等价与洛必达法则', latex: "x-\\sin x\\sim \\frac{x^{3}}{6} /n \\tan x - x \\sim \\frac{x^{3}}{3} /n \\text{若 }\\lim\\frac{f}{g}\\text{ 为 }0/0\\ 或\\ \\infty/\\infty\\text{ 型，则 }\\lim\\frac{f}{g}=\\lim\\frac{f'}{g'}", desc: '更精确展开与洛必达' },
                { title: '不定形处理技巧', latex: "0\\cdot\\infty\\to\\text{写为分式（如 }\\frac{\\ln x}{1/x}\\text{）} /n \\infty-\\infty\\to\\text{通分/有理化/提取主项} /n 1^{\\infty},0^{0},\\infty^{0}\\to\\text{取对数再处理}", desc: '不定形处理方法' },
                { title: '有理式与根式极限技巧', latex: "\\displaystyle \\lim_{x\\to\\infty}\\frac{3x^3+\\cdots}{5x^3+\\cdots}=\\frac{3}{5} /n \\sqrt{x^2+x}-x = x\\left(\\sqrt{1+\\frac{1}{x}}-1\\right)=\\dfrac{1}{\\sqrt{1+1/x}+1}", desc: '除最高阶和有理化' },
                { title: '极限重要结论与判别', latex: "\\displaystyle \\lim_{n\\to\\infty}\\sqrt[n]{a_1^n+\\cdots+a_m^n}=\\max\\{a_1,\\dots,a_m\\} /n \\displaystyle \\lim_{n\\to\\infty}e^{n x}=\\begin{cases}0,&x<0\\\\ 1,&x=0\\\\ \\infty,&x>0\\end{cases} /n \\displaystyle \\lim_{n\\to\\infty}x^{n}=\\begin{cases}0,&-1<x<1\\\\ \\infty,&|x|>1\\\\ 1,&x=1\\\\ \\text{不存在},&x=-1\\end{cases}", desc: '极限结论' },
                { title: '考试极限做题流程', latex: "1. 判定不定形 /n 2. 试等价替换 (sin~x, ln(1+x)~x) /n 3. 若失效: 除最高阶/取对数/泰勒/洛必达", desc: '极限速记流程' }
              ]
            },
            {
              title: '四、导数与求导',
              _open: false,
              children: [
                { title: '1.基本求导公式', latex: "C'=0 /n (x^{a})'=a x^{a-1} /n (a^{x})'=a^{x}\\ln a /n (e^{x})'=e^{x} /n (\\ln x)'=\\frac{1}{x}", desc: '常用基函数导数' },
                { title: '2.三角与反三角导数', latex: "(\\sin x)'=\\cos x /n (\\cos x)'=-\\sin x /n (\\tan x)'=\\sec^{2}x /n (\\arcsin x)'=\\frac{1}{\\sqrt{1-x^{2}}}", desc: '三角函数求导' },
                { title: '3.四则运算与链式法则', latex: "[u\\pm v]'=u'\\pm v' /n (uv)'=u'v+uv' /n \\left(\\frac{u}{v}\\right)'=\\frac{u'v-uv'}{v^{2}} /n (f\\circ g)'=f'(g)g'", desc: '求导运算法则' },
                { title: '4.隐函数与参数方程', latex: "若 F(x,y)=0,\\ dy/dx=-F_x/F_y /n x=\\phi(t),y=\\psi(t)\\Rightarrow dy/dx=\\psi'(t)/\\phi'(t)", desc: '隐函数求导' },
                { title: '5.变上限积分求导', latex: "\\left[\\int_{\\phi_2(x)}^{\\phi_1(x)} f(t)dt\\right]'=f(\\phi_1)\\phi_1'-f(\\phi_2)\\phi_2'", desc: '变限积分微分' },
                { title: '6.高阶导数公式', latex: "\\left(\\frac{1}{ax+b}\\right)^{(n)}=\\frac{(-1)^{n}n! a^{n}}{(ax+b)^{n+1}} /n (\\sin x)^{(n)}=\\sin\\left(x+n\\frac{\\pi}{2}\\right)", desc: '常用高阶导数' }
              ]
            },
            {
              title: '五、积分公式',
              _open: false,
              children: [
                { title: '不定积分基础', latex: "\\int x^{a}dx=\\frac{x^{a+1}}{a+1}+C\\ (a\\neq -1) /n \\int \\frac{1}{x}dx=\\ln|x|+C /n \\int e^{x}dx=e^{x}+C", desc: '常见不定积分' },
                { title: '三角与反三角积分', latex: "\\int \\sin x dx=-\\cos x + C /n \\int \\cos x dx=\\sin x + C /n \\int \\tan x dx=-\\ln|\\cos x|+C", desc: '三角函数积分' },
                { title: '倒数平方积分', latex: "\\int \\sec^{2}x dx=\\tan x + C /n \\int \\csc^{2}x dx=-\\cot x + C /n \\int \\sec x\\tan x dx=\\sec x + C", desc: '特殊三角积分' },
                { title: '反三角与根式积分', latex: "\\int \\frac{1}{a^{2}+x^{2}}dx=\\frac{1}{a}\\arctan\\frac{x}{a}+C /n \\int \\frac{1}{\\sqrt{a^{2}-x^{2}}}dx=\\arcsin\\frac{x}{a}+C /n \\int \\frac{1}{a^{2}-x^{2}}dx=\\frac{1}{2a}\\ln\\left|\\frac{a+x}{a-x}\\right|+C /n \\int \\sqrt{x}dx=\\frac{2}{3}x^{3/2}+C", desc: '反三角与根式积分' },
                { title: '华里士公式 (Wallis)', latex: "\\displaystyle \\int_{0}^{\\pi/2}\\sin^{n}x\\,dx = \\begin{cases} \\displaystyle \\frac{(2m-1)!!}{(2m)!!}\\cdot\\frac{\\pi}{2}, & n=2m\\\\[6pt] \\displaystyle \\frac{(2m)!!}{(2m+1)!!}, & n=2m+1 \\end{cases}", desc: "双阶乘公式" }
              ]
            },
            {
              title: '六、概率与统计',
              _open: false,
              children: [
                { title: '概率公理与条件概率', latex: "P(\\Omega)=1;\\ P(A)\\ge0;\\ P(A\\cup B)=P(A)+P(B)-P(A\\cap B) /n P(A|B)=\\frac{P(A\\cap B)}{P(B)}", desc: '概率基础' },
                { title: '期望、方差与协方差', latex: "E[X]=\\sum xp(x)/\\int x f(x)dx /n Var(X)=E[X^2]-E[X]^2 /n Cov(X,Y)=E[XY]-E[X]E[Y]", desc: '基本矩' },
                { title: '常见分布', latex: "Bin(n,p):P(X=k)={n\\choose k}p^k(1-p)^{n-k} /n Poisson(\\lambda):P(X=k)=e^{-\\lambda}\\lambda^k/k! /n N(\\mu,\\sigma^2): f=\\frac{1}{\\sqrt{2\\pi}\\sigma}e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}", desc: '离散与连续分布' },
                { title: '大数定律与中心极限定理', latex: "(W)LLN: \\bar{X}_n\\xrightarrow{p}E[X] /n CLT: \\frac{\\sum X_i - n\\mu}{\\sqrt{n}\\sigma} \\xrightarrow{d} N(0,1)", desc: '收敛定理' }
              ]
            },

          ],
          filteredTree: [],
          selectedFormula: null,


          // multi-pointer tracking (用于双指平移)
          activePointers: {},

          // panning with right mouse
          panningRight: false, panStartX: 0, panStartY: 0, panStartScrollLeft: 0, panStartScrollTop: 0,

          // previous centroid for two-finger pan
          prevTouchCentroid: null
        };
      },
      mounted() {
        // 从localStorage读取自定义公式

        // 优先从 localStorage 读取自定义公式
        try {
          const stored = localStorage.getItem('mathCustomFormulas');
          if (stored) {
            // stored 应该是扁平数组 [{category,title,latex,desc}, ...]
            const parsed = JSON.parse(stored);
            if (Array.isArray(parsed) && parsed.length > 0) {
              // 用 parsed 覆盖 this.tree（将扁平列表按 category 组装成 tree）
              this.importFormulaJson(parsed, { replace: true });
              console.log('mounted: 已从 localStorage 加载自定义公式并替换 tree。count=', this.customFormulas ? this.customFormulas.length : 0);
            } else {
              // 如果格式不对或为空，保持默认空 tree（或你可在此填入默认样例）
              this.filteredTree = JSON.parse(JSON.stringify(this.tree || []));
              console.log('mounted: localStorage 中 customFormulas 为空或非数组，使用默认 tree（若有）。');
            }
          } else {
            // localStorage 没有数据：不自动填充默认样例（按你要求）
            this.filteredTree = JSON.parse(JSON.stringify(this.tree || []));
            console.log('mounted: localStorage 无自定义公式，保留当前 this.tree（未覆盖默认）。');
          }
        } catch (e) {
          console.warn('mounted: 恢复 customFormulas 失败', e);
          this.filteredTree = JSON.parse(JSON.stringify(this.tree || []));
        }


        this.initCanvas();
        window.addEventListener('resize', this.resizeCanvas);
        // 禁用右键菜单（因为右键用于拖拽）
        this.$nextTick(() => {
          const c = this.$refs.container;
          if (c) c.addEventListener('contextmenu', e => e.preventDefault());
        });
        // 初始历史
        this.pushHistory();
      },


      beforeDestroy() {
        window.removeEventListener('resize', this.resizeCanvas);
      },
      methods: {

        // 上传————————————————————————————————————
        // 将 customFormulas 保存到 localStorage
        saveCustomFormulasToStorage() {
          try {
            if (!this.customFormulas) this.customFormulas = [];
            localStorage.setItem('mathCustomFormulas', JSON.stringify(this.customFormulas));
            console.log('saveCustomFormulasToStorage: saved', this.customFormulas.length);
          } catch (e) {
            console.error('保存自定义公式失败:', e);
          }
        },

        // 处理文件输入并用上传内容替换当前公式库
        handleJsonUploadReplace(e) {
          const file = e.target.files && e.target.files[0];
          // 清空 input 值，方便下次重复选择同一文件也能触发 change
          e.target.value = '';
          if (!file) return;
          const reader = new FileReader();
          reader.onload = (evt) => {
            try {
              const parsed = JSON.parse(evt.target.result);
              const ok = this.importFormulaJson(parsed, { replace: true });
              if (ok) {
                alert('已用上传的 JSON 覆盖公式库并保存到 localStorage。');
              } else {
                alert('JSON 格式不被识别或无有效公式。');
              }
            } catch (err) {
              console.error('解析 JSON 失败：', err);
              alert('解析 JSON 失败：' + (err.message || err));
            }
          };
          reader.onerror = (err) => {
            console.error('FileReader 错误', err);
            alert('读取文件失败');
          };
          reader.readAsText(file, 'utf-8');
        },

        /**
         * 导入 JSON 并可选择替换（replace=true）或合并（replace=false）
         * 支持：
         *  - tree 结构：[{ title: '分类', children: [{title, latex, desc}, ...] }, ...]
         *  - 扁平数组：[{ category?, title, latex, desc? }, ...]
         */
        importFormulaJson(data, options = { replace: true }) {
          if (!data) return false;

          const treeForm = [];
          const flatList = [];

          if (Array.isArray(data)) {
            // 判断是否为 tree（项含 children）
            const looksLikeTree = data.every(it => it && (Array.isArray(it.children)));
            if (looksLikeTree) {
              for (const cat of data) {
                const node = { title: cat.title || '未命名分类', _open: !!cat._open, children: [] };
                if (Array.isArray(cat.children)) {
                  for (const ch of cat.children) {
                    if (ch && ch.title && ch.latex) {
                      node.children.push({ title: ch.title, latex: ch.latex, desc: ch.desc || '' });
                      flatList.push({ category: node.title, title: ch.title, latex: ch.latex, desc: ch.desc || '' });
                    }
                  }
                }
                // 仅加入有子项的分类
                if (node.children.length) treeForm.push(node);
              }
            } else {
              // 扁平数组，按 category 分组
              const byCat = {};
              for (const it of data) {
                if (!it || !it.title || !it.latex) continue;
                const cat = (it.category && it.category.trim()) ? it.category.trim() : '自定义公式';
                if (!byCat[cat]) byCat[cat] = [];
                byCat[cat].push({ title: it.title, latex: it.latex, desc: it.desc || '' });
                flatList.push({ category: cat, title: it.title, latex: it.latex, desc: it.desc || '' });
              }
              for (const k of Object.keys(byCat)) {
                treeForm.push({ title: k, _open: false, children: byCat[k] });
              }
            }
          } else if (typeof data === 'object' && data !== null) {
            // 单个对象：若含 children -> 单分类；否则视为分类->数组映射
            if (Array.isArray(data.children) && data.title) {
              const node = { title: data.title, _open: !!data._open, children: [] };
              for (const ch of data.children) {
                if (ch && ch.title && ch.latex) {
                  node.children.push({ title: ch.title, latex: ch.latex, desc: ch.desc || '' });
                  flatList.push({ category: node.title, title: ch.title, latex: ch.latex, desc: ch.desc || '' });
                }
              }
              if (node.children.length) treeForm.push(node);
            } else {
              for (const k in data) {
                const arr = data[k];
                if (!Array.isArray(arr)) continue;
                const children = [];
                for (const ch of arr) {
                  if (ch && ch.title && ch.latex) {
                    children.push({ title: ch.title, latex: ch.latex, desc: ch.desc || '' });
                    flatList.push({ category: k, title: ch.title, latex: ch.latex, desc: ch.desc || '' });
                  }
                }
                if (children.length) treeForm.push({ title: k, _open: false, children });
              }
            }
          } else {
            return false;
          }

          if (treeForm.length === 0) return false;

          if (options.replace) {
            this.tree = treeForm;
          } else {
            // 可按需实现合并逻辑；此处简单前置合并（如果需要）
            this.tree = treeForm.concat(this.tree || []);
          }

          // 保存扁平化列表用于持久化
          this.customFormulas = flatList;
          this.saveCustomFormulasToStorage();

          // 更新渲染副本 & MathJax 重渲染
          this.filteredTree = JSON.parse(JSON.stringify(this.tree));
          this.$nextTick(() => {
            try {
              if (window.MathJax && MathJax.typesetPromise) {
                const panel = this.$el.querySelector('.formula-panel');
                if (panel) MathJax.typesetPromise([panel]).catch(() => { });
                else MathJax.typesetPromise().catch(() => { });
              }
            } catch (e) { /* ignore */ }
          });

          console.log('importFormulaJson: 完成，categories=', this.tree.length, 'items=', this.customFormulas.length);
          return true;
        },
        // 上传结束————————————————————————




        // ---------- formula ----------
        toggleFormulaPanel() { this.showFormulaPanel = !this.showFormulaPanel; if (this.showFormulaPanel) { this.filteredTree = JSON.parse(JSON.stringify(this.tree)); this.$nextTick(() => { if (window.MathJax && MathJax.typesetPromise) MathJax.typesetPromise().catch(() => { }); }); } },
        closeFormulaPanel() { this.showFormulaPanel = false; this.selectedFormula = null; this.filteredTree = JSON.parse(JSON.stringify(this.tree)); },
        toggleNode(n) { n._open = !n._open; },
        selectFormula(leaf) {
          this.selectedFormula = {
            title: leaf.title || '公式',
            latex: leaf.latex || '',
            desc: leaf.desc || ''
          };

          const raw = this.selectedFormula.latex || '';
          // 把 /n 和真实换行 都替换成 TeX 的换行 \\ （注意 JS 字面量里写成 '\\\\'）
          const withLineBreaks = raw
            .replace(/\/n/g, '\\\\')
            .replace(/\r?\n/g, '\\\\');

          this.selectedFormula.latexHtml =
            '$$\\begin{array}{l}' + withLineBreaks + '\\end{array}$$'

          // 描述里把 /n 或实际换行换成 <br/>（便于 HTML 显示）
          this.selectedFormula.descHtml = (this.selectedFormula.desc || '')
            .replace(/\/n/g, '<br/>')
            .replace(/\r?\n/g, '<br/>');

          this.$nextTick(() => {
            try {
              if (window.MathJax && MathJax.typesetPromise) {
                const panel = this.$el.querySelector('.formula-panel');
                if (panel) MathJax.typesetPromise([panel]).catch(() => { });
                else MathJax.typesetPromise().catch(() => { });
              }
            } catch (e) { }
          });
        },


        // ---------- history ----------
        cloneImageData(src) { if (!src) return null; return new ImageData(new Uint8ClampedArray(src.data), src.width, src.height); },
        pushHistory() {
          try {
            const img = this.drawingCtx.getImageData(0, 0, this.canvasWidth, this.canvasHeight);
            const imgCopy = this.cloneImageData(img);
            let selCopy = null;
            if (this.selection) {
              selCopy = { x: this.selection.x, y: this.selection.y, width: this.selection.width, height: this.selection.height, imageData: this.cloneImageData(this.selection.imageData), scaleX: this.selection.scaleX, scaleY: this.selection.scaleY, polygon: this.selection.polygon ? JSON.parse(JSON.stringify(this.selection.polygon)) : null };
            }
            this.undoStack.push({ image: imgCopy, selection: selCopy });
            if (this.undoStack.length > this.maxHistory) this.undoStack.shift();
            this.redoStack = [];
          } catch (e) { console.warn('pushHistory failed', e); }
        },
        applyState(state) {
          if (!state) return;
          try {
            this.drawingCtx.putImageData(state.image, 0, 0);
            if (state.selection) {
              const s = state.selection;
              this.selection = { x: s.x, y: s.y, width: s.width, height: s.height, imageData: this.cloneImageData(s.imageData), scaleX: s.scaleX, scaleY: s.scaleY, polygon: s.polygon ? JSON.parse(JSON.stringify(s.polygon)) : null };
            } else this.selection = null;
            this.clearCursorCanvas();
            this.redrawSelectionLayer();
          } catch (e) { console.warn('applyState failed', e); }
        },
        undo() { if (this.undoStack.length <= 1) return; const cur = this.undoStack.pop(); this.redoStack.push(cur); const prev = this.undoStack[this.undoStack.length - 1]; this.applyState(prev); },
        redo() { if (this.redoStack.length === 0) return; const s = this.redoStack.pop(); this.undoStack.push(s); this.applyState(s); },


        // ---------- canvas init / resize ----------
        initCanvas() {
          const drawingCanvas = this.$refs.drawingCanvas, cursorCanvas = this.$refs.cursorCanvas, container = this.$refs.container;
          if (this.$refs.spacer) this.$refs.spacer.style.width = this.canvasWidth + 'px', this.$refs.spacer.style.height = this.canvasHeight + 'px';
          [drawingCanvas, cursorCanvas].forEach(c => {
            c.width = this.canvasWidth; c.height = this.canvasHeight;
            c.style.width = this.canvasWidth + 'px'; c.style.height = this.canvasHeight + 'px';
            c.style.left = '0px'; c.style.top = '0px';
          });
          // by default allow browser gestures; when drawing single finger we switch to 'none'
          this.$refs.drawingCanvas.style.touchAction = 'none'; // 我们完全用 pointer 事件实现交互
          this.drawingCtx = drawingCanvas.getContext('2d');
          this.cursorCtx = cursorCanvas.getContext('2d');
          this.drawingCtx.lineCap = 'round'; this.drawingCtx.lineJoin = 'round';
          this.clearCursorCanvas();
          this.redrawSelectionLayer();
        },

        resizeCanvas() {
          const container = this.$refs.container;
          if (!container) return;
          const newWidth = Math.max(800, container.clientWidth);
          if (newWidth === this.canvasWidth) return;
          try {
            const oldImg = this.drawingCtx.getImageData(0, 0, this.canvasWidth, this.canvasHeight);
            this.canvasWidth = newWidth;
            [this.$refs.drawingCanvas, this.$refs.cursorCanvas].forEach(c => {
              c.width = this.canvasWidth;
              c.style.width = this.canvasWidth + 'px';
              c.height = this.canvasHeight;
              c.style.height = this.canvasHeight + 'px';
            });
            this.drawingCtx.putImageData(oldImg, 0, 0);
          } catch (e) {
            this.canvasWidth = newWidth;
            [this.$refs.drawingCanvas, this.$refs.cursorCanvas].forEach(c => {
              c.width = this.canvasWidth; c.style.width = this.canvasWidth + 'px';
              c.height = this.canvasHeight; c.style.height = this.canvasHeight + 'px';
            });
          }
          if (this.$refs.spacer) this.$refs.spacer.style.width = this.canvasWidth + 'px';
          this.clearCursorCanvas(); this.redrawSelectionLayer();
        },


        // ---------- expand in four directions (手动触发) ----------
        expandCanvasDir(leftAdd, rightAdd, topAdd, bottomAdd) {
          if (!leftAdd && !rightAdd && !topAdd && !bottomAdd) return;
          const newW = this.canvasWidth + (leftAdd || 0) + (rightAdd || 0);
          const newH = this.canvasHeight + (topAdd || 0) + (bottomAdd || 0);
          if (newW > this.maxDim || newH > this.maxDim) return;
          const container = this.$refs.container;
          const prevScrollLeft = container.scrollLeft;
          const prevScrollTop = container.scrollTop;
          const tmp = document.createElement('canvas');
          tmp.width = newW; tmp.height = newH;
          const tctx = tmp.getContext('2d');
          tctx.fillStyle = '#000'; tctx.fillRect(0, 0, newW, newH);
          tctx.drawImage(this.$refs.drawingCanvas, (leftAdd || 0), (topAdd || 0));

          if (this.selection) {
            this.selection.x += (leftAdd || 0);
            this.selection.y += (topAdd || 0);
            if (this.selection.polygon && Array.isArray(this.selection.polygon)) {
              for (const p of this.selection.polygon) { p.x += (leftAdd || 0); p.y += (topAdd || 0); }
            }
          }
          if (this.freePath && this.freePath.length > 0) {
            for (const p of this.freePath) { p.x += (leftAdd || 0); p.y += (topAdd || 0); }
          }

          this.canvasWidth = newW; this.canvasHeight = newH;
          [this.$refs.drawingCanvas, this.$refs.cursorCanvas].forEach(c => {
            c.width = this.canvasWidth; c.height = this.canvasHeight;
            c.style.width = this.canvasWidth + 'px'; c.style.height = this.canvasHeight + 'px';
          });

          this.drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
          this.drawingCtx.drawImage(tmp, 0, 0);

          if (this.$refs.spacer) { this.$refs.spacer.style.width = this.canvasWidth + 'px'; this.$refs.spacer.style.height = this.canvasHeight + 'px'; }

          if (leftAdd) container.scrollLeft = prevScrollLeft + leftAdd;
          if (topAdd) container.scrollTop = prevScrollTop + topAdd;

          this.clearCursorCanvas();
          this.redrawSelectionLayer();
        },

        expandRight() { this.expandCanvasDir(0, this.expandStep, 0, 0); },
        expandLeft() { this.expandCanvasDir(this.expandStep, 0, 0, 0); },
        expandTop() { this.expandCanvasDir(0, 0, this.expandStep, 0); },
        expandBottom() { this.expandCanvasDir(0, 0, 0, this.expandStep); },


        // ---------- interaction toggles ----------
        togglePanel(panel) {
          this.toggleBodyScroll(panel !== null);
          if (this.activePanel === panel) {
            this.activePanel = null;
            this.isEraser = false;
            this.activePanel2 = 'true'
            this.$refs.drawingCanvas.style.touchAction = 'none';
          } else {
            this.activePanel2 = 'true';
            this.activePanel = panel;
            this.isEraser = (panel === 'eraser');
            if (panel === 'brush' || panel === 'eraser') {
              this.$refs.drawingCanvas.style.touchAction = 'none';
            }
          }
          this.selectMode = false;
          this.freeSelectMode = false;
        },
        toggleBodyScroll(disable) {
          const body = document.body;
          if (disable) {
            body.style.overflow = 'hidden';
            this.scrollTop = window.pageYOffset || document.documentElement.scrollTop;
            body.style.position = 'fixed';
            body.style.top = `-${this.scrollTop}px`;
            body.style.width = '100%';
          } else {
            body.style.overflow = '';
            body.style.position = '';
            body.style.top = '';
            body.style.width = '';
            window.scrollTo(0, this.scrollTop);
          }
        },

        activateSelectTool() { this.selectMode = !this.selectMode; this.activePanel = null; this.isEraser = false; this.drawing = false; this.clearCursorCanvas(); this.selectionStart = null; this.selectionEnd = null; this.dragging = false; this.resizing = false; this.freeSelectMode = false; this.freeSelecting = false; this.freePath = []; },
        toggleFreeSelect() { this.freeSelectMode = !this.freeSelectMode; this.selectMode = false; this.activePanel = null; this.isEraser = false; this.drawing = false; this.clearCursorCanvas(); this.freeSelecting = false; this.freePath = []; },


        // ---------- draw helpers ----------
        startDrawFromPos(pos) {
          if (this.selectMode || this.freeSelectMode) return;
          this.drawing = true; this.lastX = pos.x; this.lastY = pos.y;

          this.activePanel2 = 'false';
          this.drawingCtx.strokeStyle = this.isEraser ? '#000' : this.color;
          this.drawingCtx.lineWidth = this.isEraser ? this.eraserSize : this.size;
          this.drawingCtx.beginPath();
          this.drawingCtx.moveTo(this.lastX, this.lastY);
          if (this.isEraser) this.updateEraserCursor(this.lastX, this.lastY);
        },
        drawToPos(pos) {
          if (this.selectMode || this.freeSelectMode) return;
          if (!this.drawing) return;
          // 自动扩展已被禁用（改为手动 expand 按钮）
          this.lastX = pos.x; this.lastY = pos.y;
          this.drawingCtx.lineTo(this.lastX, this.lastY);
          if (this.isEraser) { this.drawingCtx.save(); this.drawingCtx.globalCompositeOperation = 'destination-out'; this.drawingCtx.stroke(); this.drawingCtx.restore(); }
          else this.drawingCtx.stroke();
          if (this.isEraser) this.updateEraserCursor(this.lastX, this.lastY);
        },
        stopDraw() {
          if (this.selectMode || this.freeSelectMode) return;
          if (!this.drawing) return;
          this.drawing = false;
          this.clearCursorCanvas(); this.eraserCursorX = null; this.eraserCursorY = null;
          this.pushHistory();
        },


        setLastPosFromEvent(e) { const rect = this.$refs.drawingCanvas.getBoundingClientRect(); this.lastX = e.clientX - rect.left; this.lastY = e.clientY - rect.top; },
        getPosFromEvent(e) { const rect = this.$refs.drawingCanvas.getBoundingClientRect(); return { x: Math.round(e.clientX - rect.left), y: Math.round(e.clientY - rect.top) }; },


        updateEraserCursor(x, y) { this.eraserCursorX = x; this.eraserCursorY = y; this.clearCursorCanvas(); this.drawEraserCursor(); },
        drawEraserCursor() { if (this.eraserCursorX === null || this.eraserCursorY === null) return; const ctx = this.cursorCtx; ctx.save(); ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); ctx.beginPath(); ctx.strokeStyle = 'rgba(0,255,0,0.6)'; ctx.lineWidth = 2; ctx.arc(this.eraserCursorX, this.eraserCursorY, this.eraserSize / 2, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); },
        clearCursorCanvas() { if (!this.cursorCtx) return; this.cursorCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); if (!this.selection) { this.deleteBtnLeft = 0; this.deleteBtnTop = 0; } },


        // ---------- selection/free path ----------
        drawSelectionRect() { if (!this.selectionStart || !this.selectionEnd) return; const ctx = this.cursorCtx; ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); ctx.save(); ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); const r = this.getNormalizedRect(this.selectionStart, this.selectionEnd); ctx.strokeRect(r.x, r.y, r.width, r.height); ctx.restore(); },
        getNormalizedRect(p1, p2) { const x = Math.min(p1.x, p2.x), y = Math.min(p1.y, p2.y); const width = Math.abs(p1.x - p2.x), height = Math.abs(p1.y - p2.y); return { x, y, width, height }; },


        isPointInSelection(point) {
          if (!this.selection) return false;
          const s = this.selection; const scaleX = s.scaleX || 1, scaleY = s.scaleY || 1;
          if (s.polygon && Array.isArray(s.polygon)) {
            const poly = s.polygon.map(p => ({ x: s.x + p.x * scaleX, y: s.y + p.y * scaleY }));
            return this.pointInPolygon(point.x, point.y, poly);
          } else {
            const right = s.x + s.width * scaleX, bottom = s.y + s.height * scaleY;
            return (point.x >= s.x && point.x <= right && point.y >= s.y && point.y <= bottom);
          }
        },


        isOverResizeHandle(point) {
          if (!this.selection) return false;
          const s = this.selection; const scaleX = s.scaleX || 1, scaleY = s.scaleY || 1;
          const handleSize = this.resizeHandleSize; let right, bottom;
          if (s.polygon && Array.isArray(s.polygon)) {
            const transformed = s.polygon.map(p => ({ x: s.x + p.x * scaleX, y: s.y + p.y * scaleY }));
            const bbox = this.getPolygonBBox(transformed); right = bbox.x + bbox.width; bottom = bbox.y + bbox.height;
          } else { right = s.x + s.width * scaleX; bottom = s.y + s.height * scaleY; }
          return (point.x >= right - handleSize && point.x <= right + handleSize && point.y >= bottom - handleSize && point.y <= bottom + handleSize);
        },


        redrawSelectionLayer() {
          this.clearCursorCanvas();
          if (!this.selection) return;
          const ctx = this.cursorCtx; ctx.save(); ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
          const s = this.selection; const scaleX = s.scaleX || 1, scaleY = s.scaleY || 1;
          const off = document.createElement('canvas'); off.width = s.width; off.height = s.height; const offCtx = off.getContext('2d');
          offCtx.putImageData(s.imageData, 0, 0);
          ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
          ctx.drawImage(off, 0, 0, s.width, s.height, s.x, s.y, s.width * scaleX, s.height * scaleY);
          ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.strokeStyle = '#0f0';
          let bbox = null;
          if (s.polygon && Array.isArray(s.polygon)) {
            const transformed = s.polygon.map(p => ({ x: s.x + p.x * scaleX, y: s.y + p.y * scaleY }));
            ctx.beginPath(); for (let i = 0; i < transformed.length; i++) { const pt = transformed[i]; if (i === 0) ctx.moveTo(pt.x, pt.y); else ctx.lineTo(pt.x, pt.y); } ctx.closePath(); ctx.stroke();
            ctx.setLineDash([]); bbox = this.getPolygonBBox(transformed);
            ctx.setLineDash([6, 4]); ctx.strokeRect(bbox.x, bbox.y, bbox.width, bbox.height); ctx.setLineDash([]);
            const h = this.resizeHandleSize; ctx.fillStyle = '#0f0'; ctx.fillRect(bbox.x + bbox.width - h / 2, bbox.y + bbox.height - h / 2, h, h);
          } else {
            ctx.beginPath(); ctx.strokeRect(s.x, s.y, s.width * scaleX, s.height * scaleY); ctx.closePath(); ctx.setLineDash([]);
            bbox = { x: s.x, y: s.y, width: s.width * scaleX, height: s.height * scaleY };
            const h = this.resizeHandleSize; ctx.fillStyle = '#0f0'; ctx.fillRect(s.x + s.width * scaleX - h / 2, s.y + s.height * scaleY - h / 2, h, h);
          }
          ctx.restore();
          if (bbox) {
            const offset = 6;
            const btnLeft = Math.min(Math.max(0, Math.round(bbox.x + bbox.width + offset)), this.canvasWidth - 10);
            const btnTop = Math.min(Math.max(0, Math.round(bbox.y + bbox.height + offset)), this.canvasHeight - 10);
            this.deleteBtnLeft = btnLeft; this.deleteBtnTop = btnTop;
          }
        },


        deleteSelection() { if (!this.selection) return; this.selection = null; this.clearCursorCanvas(); this.pushHistory(); },


        drawFreePath() { const ctx = this.cursorCtx; ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); if (!this.freePath || this.freePath.length === 0) return; ctx.save(); ctx.strokeStyle = '#0f0'; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.beginPath(); for (let i = 0; i < this.freePath.length; i++) { const p = this.freePath[i]; if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); } ctx.stroke(); ctx.fillStyle = '#0f0'; ctx.setLineDash([]); const st = this.freePath[0]; ctx.beginPath(); ctx.arc(st.x, st.y, 3, 0, Math.PI * 2); ctx.fill(); ctx.restore(); },

        getBBoxFromPath(path) {
          let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
          for (const p of path) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; }
          minX = Math.max(0, Math.floor(minX)); minY = Math.max(0, Math.floor(minY));
          maxX = Math.min(this.canvasWidth, Math.ceil(maxX)); maxY = Math.min(this.canvasHeight, Math.ceil(maxY));
          return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
        },

        pointInPolygon(x, y, polygon) {
          let inside = false;
          for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
            const xi = polygon[i].x, yi = polygon[i].y; const xj = polygon[j].x, yj = polygon[j].y;
            const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi + 1e-8) + xi);
            if (intersect) inside = !inside;
          }
          return inside;
        },
        getPolygonBBox(points) { if (!points || points.length === 0) return { x: 0, y: 0, width: 0, height: 0 }; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const p of points) { if (p.x < minX) minX = p.x; if (p.y < minY) minY = p.y; if (p.x > maxX) maxX = p.x; if (p.y > maxY) maxY = p.y; } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; },


        // ---------- pointer handlers with multi-touch pass-through ----------
        onPointerDown(e) {
          // 记录 pointer 并保存位置
          this.activePointers[e.pointerId] = { id: e.pointerId, type: e.pointerType, x: e.clientX, y: e.clientY };

          const ids = Object.keys(this.activePointers);
          const touchCount = ids.filter(id => this.activePointers[id].type === 'touch').length;

          // 鼠标右键用于平移
          if (e.pointerType === 'mouse' && e.button === 2) {
            this.panningRight = true;
            this.panStartX = e.clientX;
            this.panStartY = e.clientY;
            const container = this.$refs.container;
            this.panStartScrollLeft = container.scrollLeft;
            this.panStartScrollTop = container.scrollTop;
            try { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); } catch (err) { }
            e.preventDefault();
            return;
          }

          // 当有两个触点时（双指）我们将由 JS 自行处理平移，所以阻止浏览器默认的滚动
          if (touchCount >= 2) {
            // 初始化质心（用于计算移动量）
            this.prevTouchCentroid = this.calculateTouchCentroid();
            e.preventDefault();
            try { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); } catch (err) { }
            return;
          }

          // UI 元素不处理绘制
          const el = e.target || e.srcElement;
          if (this.isEventFromUI(el)) return;


          // 只在允许交互的模式下处理单指绘制/选区：
          const allowInteraction = this.selectMode || this.freeSelectMode || this.activePanel === 'brush' || this.activePanel === 'eraser' || !!this.selection;
          if (!allowInteraction) return; // 不阻塞滚动

          // 捕获指针
          try { e.target.setPointerCapture && e.target.setPointerCapture(e.pointerId); } catch (err) { }

          // 如果已有 selection，处理拖动/缩放或将选区释放回画布
          if (this.selection) {
            const pos = this.getPosFromEvent(e);
            if (this.isOverResizeHandle(pos)) { this.resizing = true; this.dragStart = pos; this.startSelectionWidth = this.selection.width; this.startSelectionHeight = this.selection.height; this.startScaleX = this.selection.scaleX || 1; this.startScaleY = this.selection.scaleY || 1; return; }
            if (this.isPointInSelection(pos)) { this.dragging = true; this.dragStart = pos; this.selection.dragOffsetX = pos.x - this.selection.x; this.selection.dragOffsetY = pos.y - this.selection.y; return; }
            const s = this.selection;
            const offCanvas = document.createElement('canvas'); offCanvas.width = s.width; offCanvas.height = s.height;
            const offCtx = offCanvas.getContext('2d'); offCtx.putImageData(s.imageData, 0, 0);
            const scaleX = s.scaleX || 1, scaleY = s.scaleY || 1;
            this.drawingCtx.drawImage(offCanvas, 0, 0, offCanvas.width, offCanvas.height, s.x, s.y, offCanvas.width * scaleX, offCanvas.height * scaleY);
            this.selection = null; this.clearCursorCanvas(); this.pushHistory(); return;
          }

          if (this.freeSelectMode) { const pos = this.getPosFromEvent(e); this.freeSelecting = true; this.freePath = [{ x: pos.x, y: pos.y }]; this.clearCursorCanvas(); this.drawFreePath(); return; }
          if (this.selectMode) { const pos = this.getPosFromEvent(e); this.selecting = true; this.selectionStart = pos; this.selectionEnd = pos; this.clearCursorCanvas(); this.redrawSelectionLayer(); return; }


          // normal drawing - only allowed when brush or eraser active and primary button
          if (this.activePanel === 'brush' || this.activePanel === 'eraser') {
            // 鼠标仅允许主键绘制（一般是左键）
            if (e.pointerType === 'mouse' && e.button !== 0) return;
            const pos = this.getPosFromEvent(e);
            this.startDrawFromPos(pos);
          }
        },


        onPointerMove(e) {
          // 更新记录
          if (this.activePointers[e.pointerId]) this.activePointers[e.pointerId] = { id: e.pointerId, type: e.pointerType, x: e.clientX, y: e.clientY };

          // 如果在右键平移中
          if (this.panningRight && e.pointerType === 'mouse') {
            const dx = e.clientX - this.panStartX;
            const dy = e.clientY - this.panStartY;
            const container = this.$refs.container;
            container.scrollLeft = Math.max(0, this.panStartScrollLeft - dx);
            container.scrollTop = Math.max(0, this.panStartScrollTop - dy);
            e.preventDefault();
            return;
          }

          // 检查当前 touch 数量
          const ids = Object.keys(this.activePointers);
          const touches = ids.filter(id => this.activePointers[id] && this.activePointers[id].type === 'touch');
          const touchCount = touches.length;

          // 双指：使用质心差值实现平移
          if (touchCount >= 2) {
            const centroid = this.calculateTouchCentroid();
            if (this.prevTouchCentroid) {
              const dx = centroid.x - this.prevTouchCentroid.x;
              const dy = centroid.y - this.prevTouchCentroid.y;
              const container = this.$refs.container;
              container.scrollLeft = Math.max(0, container.scrollLeft - dx);
              container.scrollTop = Math.max(0, container.scrollTop - dy);
            }
            this.prevTouchCentroid = centroid;
            e.preventDefault();
            return;
          }

          // 如果正在绘制/选区，阻止默认
          if (this.drawing || this.selecting || this.freeSelecting) {
            e.preventDefault();
          }

          if (!this.drawing && !this.dragging && !this.resizing && !this.freeSelecting && !this.selecting) return;

          const pos = this.getPosFromEvent(e);
          if (this.dragging) { this.selection.x = pos.x - this.selection.dragOffsetX; this.selection.y = pos.y - this.selection.dragOffsetY; this.clearCursorCanvas(); this.redrawSelectionLayer(); return; }
          if (this.resizing) { const dx = pos.x - this.dragStart.x, dy = pos.y - this.dragStart.y; const newScaleX = Math.max(0.05, this.startScaleX * ((this.startSelectionWidth + dx) / this.startSelectionWidth)); const newScaleY = Math.max(0.05, this.startScaleY * ((this.startSelectionHeight + dy) / this.startSelectionHeight)); this.selection.scaleX = newScaleX; this.selection.scaleY = newScaleY; this.clearCursorCanvas(); this.redrawSelectionLayer(); return; }
          if (this.freeSelectMode) { if (!this.freeSelecting) return; this.freePath.push({ x: pos.x, y: pos.y }); this.drawFreePath(); return; }
          if (this.selectMode) { if (this.selecting) { this.selectionEnd = pos; this.clearCursorCanvas(); this.drawSelectionRect(); return; } }
          // drawing:
          this.drawToPos(pos);
        },


        onPointerUp(e) {
          // 释放记录
          delete this.activePointers[e.pointerId];
          try { e.target.releasePointerCapture && e.target.releasePointerCapture(e.pointerId); } catch (err) { }

          // 结束右键平移
          if (this.panningRight && e.pointerType === 'mouse') { this.panningRight = false; return; }

          // touch 平移重置
          const ids = Object.keys(this.activePointers);
          const touchCount = ids.filter(id => this.activePointers[id] && this.activePointers[id].type === 'touch').length;
          if (touchCount < 2) this.prevTouchCentroid = null;

          if (this.dragging) { this.dragging = false; this.clearCursorCanvas(); this.redrawSelectionLayer(); this.pushHistory(); }
          if (this.resizing) { this.resizing = false; this.clearCursorCanvas(); this.redrawSelectionLayer(); this.pushHistory(); }


          if (this.freeSelectMode) {
            if (!this.freeSelecting) { }
            else {
              this.freeSelecting = false;
              if (!this.freePath || this.freePath.length < 3) { this.freePath = []; this.clearCursorCanvas(); return; }
              const bbox = this.getBBoxFromPath(this.freePath);
              if (bbox.width < 2 || bbox.height < 2) { this.freePath = []; this.clearCursorCanvas(); return; }
              const imgData = this.drawingCtx.getImageData(bbox.x, bbox.y, bbox.width, bbox.height);
              const offCanvas = document.createElement('canvas'); offCanvas.width = Math.max(1, bbox.width); offCanvas.height = Math.max(1, bbox.height);
              const offCtx = offCanvas.getContext('2d'); offCtx.putImageData(imgData, 0, 0);
              const maskCanvas = document.createElement('canvas'); maskCanvas.width = offCanvas.width; maskCanvas.height = offCanvas.height;
              const maskCtx = maskCanvas.getContext('2d'); maskCtx.fillStyle = '#000'; maskCtx.beginPath();
              for (let i = 0; i < this.freePath.length; i++) { const p = this.freePath[i]; const rx = p.x - bbox.x, ry = p.y - bbox.y; if (i === 0) maskCtx.moveTo(rx, ry); else maskCtx.lineTo(rx, ry); }
              maskCtx.closePath(); maskCtx.fill();
              offCtx.globalCompositeOperation = 'destination-in'; offCtx.drawImage(maskCanvas, 0, 0); offCtx.globalCompositeOperation = 'source-over';
              this.drawingCtx.save(); this.drawingCtx.globalCompositeOperation = 'destination-out'; this.drawingCtx.beginPath();
              for (let i = 0; i < this.freePath.length; i++) { const p = this.freePath[i]; if (i === 0) this.drawingCtx.moveTo(p.x, p.y); else this.drawingCtx.lineTo(p.x, p.y); }
              this.drawingCtx.closePath(); this.drawingCtx.fill(); this.drawingCtx.restore();
              const finalImageData = offCtx.getImageData(0, 0, offCanvas.width, offCanvas.height);
              const relativePoly = this.freePath.map(p => ({ x: p.x - bbox.x, y: p.y - bbox.y }));
              this.selection = { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height, imageData: finalImageData, scaleX: 1, scaleY: 1, polygon: relativePoly };
              this.freePath = []; this.clearCursorCanvas(); this.redrawSelectionLayer(); this.pushHistory();
              return;
            }
          }


          if (this.selectMode) {
            if (this.selecting) {
              this.selecting = false;
              if (!this.selectionStart || !this.selectionEnd) return;
              const rect = this.getNormalizedRect(this.selectionStart, this.selectionEnd);
              if (rect.width < 5 || rect.height < 5) { this.clearCursorCanvas(); this.selectionStart = null; this.selectionEnd = null; return; }
              const imageData = this.drawingCtx.getImageData(rect.x, rect.y, rect.width, rect.height);
              this.drawingCtx.clearRect(rect.x, rect.y, rect.width, rect.height);
              this.selection = { x: rect.x, y: rect.y, width: rect.width, height: rect.height, imageData, scaleX: 1, scaleY: 1, polygon: null };
              this.clearCursorCanvas(); this.redrawSelectionLayer(); this.pushHistory(); this.selectionStart = null; this.selectionEnd = null;
              return;
            }
          }

          // drawing end
          if (this.drawing) this.stopDraw();


        },


        // ---------- clear ----------
        clearCanvas() {

          try { this.drawingCtx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); } catch (e) { console.warn(e); }
          this.selection = null; this.clearCursorCanvas(); this.pushHistory();
        },


        // ---------- helpers ----------
        isEventFromUI(el) {
          if (!el) return false;
          if (el.closest && (el.closest('.toolbar') || el.closest('.formula-panel') || el.closest('.config-panel'))) return true;
          const tag = (el.tagName || '').toUpperCase();
          if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'LABEL' || tag === 'SELECT' || tag === 'TEXTAREA') return true;
          return false;
        },

        calculateTouchCentroid() {
          const ids = Object.keys(this.activePointers);
          const touches = ids.filter(id => this.activePointers[id] && this.activePointers[id].type === 'touch').map(id => this.activePointers[id]);
          if (touches.length === 0) return null;
          let sx = 0, sy = 0;
          for (const t of touches) { sx += t.x; sy += t.y; }
          return { x: sx / touches.length, y: sy / touches.length };
        }
      }
    });
  </script>
</body>

</html>